Skip to content

C++ 语⾔基础篇

1、说⼀下你理解的 C++ 中的四种智能指针

C++ 语⾔基础篇 图 4

⾯试官你好,⾸先,说⼀下为什么要使⽤智能指针:智能指针其作⽤是管理⼀个指针,避免咋们程序员申请的空间在函数结束时忘记释放,造成内存泄漏这种情况滴发⽣。

然后使⽤智能指针可以很⼤程度上的避免这个问题,因为智能指针就是⼀个类,当超出了类的作⽤域是,类会⾃动调⽤析构函数,析构函数会⾃动释放资源。所以智能指针的作⽤原理就是在函数结束时⾃动释放内存空间,不需要⼿动释放内存空间。

常⽤接⼝

cpp
T* get();
T& operator*();
T* operator->();
T& operator=(const T& val);
T* release();
void reset (T* ptr = nullptr);
  • T 是模板参数, 也就是传⼊的类型;
  • get() ⽤来获取 auto_ptr 封装在内部的指针, 也就是获取原⽣指针;
  • operator() 重载 , operator->() 重载了->, operator=()重载了=
  • realease()auto_ptr 封装在内部的指针置为 nullptr, 但并不会破坏指针所指向的内容, 函数返回的是内部指针置空之前的值;

直接释放封装的内部指针所指向的内存, 如果指定了 ptr 的值, 则将内部指针初始化为该值(否则将其设置为nullptr;

下⾯分别说⼀下哪四种:

1、auto_ptr(C++98 的⽅案,C11 已抛弃)采⽤所有权模式。

cpp
auto_ptr<std::string> p1 (new string ("hello"));
auto_ptr<std::string> p2;
p2 = p1; //auto_ptr 不会报错.

此时不会报错,p2 剥夺了 p1 的所有权,但是当程序运⾏时访问 p1 将会报错。所以 auto_ptr的缺点是:存在潜在的内存崩溃问题!

2、unique_ptr(替换 auto_ptr**unique_ptr 实现独占式拥有或严格拥有概念,保证同⼀时间内只有⼀个智能指针可以指向该对象。它对于避免资源泄露特别有⽤。

采⽤所有权模式,还是上⾯那个例⼦

cpp
unique_ptr<string> p3 (new string (auto));//#4
unique_ptr<string> p4;//#5
p4 = p3;//此时会报错编译器认为 p4=p3 ⾮法,避免了 p3 不再指向有效数据的问题。

因此,unique_ptr ⽐ auto_ptr 更安全。

3、shared_ptr(共享型,强引⽤)shared_ptr 实现共享式拥有概念,多个智能指针可以指向相同对象,该对象和其相关资源会在“最后⼀个引⽤被销毁”时候释放。从名字 share 就可以看出了资源可以被多个指针共享,它使⽤计数机制来表明资源被⼏个指针共享。

可以通过成员函数 use_count() 来查看资源的所有者个数,除了可以通过 new 来构造,还可以通过传⼊auto_ptr, unique_ptr,weak_ptr 来构造。当我们调⽤ release() 时,当前指针会释放资源所有权,计数减⼀。当计数等于 0 时,资源会被释放。

shared_ptr 是为了解决 auto_ptr 在对象所有权上的局限性 (auto_ptr 是独占的),在使⽤引⽤计数的机制上提供了可以共享所有权的智能指针。

4、weak_ptr(弱引⽤)weak_ptr 是⼀种不控制对象⽣命周期的智能指针,它指向⼀个 shared_ptr 管理的对象。进⾏该对象的内存管理的是那个强引⽤的 shared_ptr

weak_ptr 只是提供了对管理对象的⼀个访问⼿段。weak_ptr 设计的⽬的是为配合shared_ptr ⽽引⼊的⼀种智能指针来协助 shared_ptr ⼯作,它只可以从⼀个 shared_ptr 或另⼀个 weak_ptr 对象构造,它的构造和析构不会引起引⽤记数的增加或减少。

weak_ptr 是⽤来解决 shared_ptr 相互引⽤时的死锁问题,如果说两个 shared_ptr 相互引⽤,那么这两个指针的引⽤计数永远不可能下降为0,也就是资源永远不会释放。它是对对象的⼀种弱引⽤,不会增加对象的引⽤计数,和 shared_ptr 之间可以相互转化,shared_ptr 可以直接赋值给它,它可以通过调⽤ lock 函数来获得shared_ptr

c
shared_ptr<A> a = make_shared<A>();  // A 的引用计数 = 1
shared_ptr<B> b = make_shared<B>();  // B 的引用计数 = 1

a->ptr_b = b;  // A 内部持有了 b → B 的引用计数变成 2
b->ptr_a = a;  // B 内部持有了 a → A 的引用计数变成 2

当两个智能指针都是 shared_ptr 类型的时候,析构时两个资源引⽤计数会减⼀,但是两者引⽤计数还是为 1,导致跳出函数时资源没有被释放(的析构函数没有被调⽤),解决办法:把其中⼀个改为weak_ptr就可以。

shared_ptr→ weak_ptr(隐式转换)

c
shared_ptr<int> sp = make_shared<int>(42);
    
// 方法1:构造时传入
weak_ptr<int> wp1(sp);

// 方法2:直接赋值
weak_ptr<int> wp2;
wp2 = sp;

weak_ptr→ shared_ptr(需要显式调用 lock())

c
#include <iostream>
#include <memory>
using namespace std;

int main() {
    shared_ptr<int> sp = make_shared<int>(100);
    weak_ptr<int> wp = sp;
    
    // 方法1:使用 lock() 获取 shared_ptr
    shared_ptr<int> sp2 = wp.lock();
    if (sp2) {
        cout << "成功获取,值 = " << *sp2 << endl;       // 100
        cout << "sp2.use_count() = " << sp2.use_count() << endl; // 2
    }
    
    // 方法2:直接在条件判断中使用
    if (auto sp3 = wp.lock()) {
        cout << "sp3 也获得了共享所有权" << endl;
        cout << "此时 use_count = " << sp.use_count() << endl; // 3
    }
    // sp3 离开作用域后,引用计数降回 2
    
    return 0;
}

2、C++ 中内存分配情况

栈:由编译器管理分配和回收,存放局部变量和函数参数。

堆:由程序员管理,需要⼿动 new/malloc, delete/free 进⾏分配和回收,空间较⼤,但可能会出现内存泄漏和空闲碎⽚的情况。

全局/静态存储区:分为初始化和未初始化两个相邻区域,存储初始化和未初始化的全局变量和静态变量

常量存储区:存储常量,⼀般不允许修改。

代码区:存放程序的⼆进制代码。

3、C++ 中的指针参数传递和引⽤参数传递

指针参数传递本质上是值传递,它所传递的是⼀个地址值。值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从⽽形成了实参的⼀个副本(替身)。值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进⾏的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会变)。

引⽤参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。

引⽤传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的⼀个局部变量,但是任何对于引⽤参数的处理都会通过⼀个间接寻址的⽅式操作到主调函数中的相关变量。⽽对于指针传递的参数,如果改变被调函数中的指针地址,它将应⽤不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使⽤指向指针的指针或者指针引⽤。

从编译的⻆度来讲,程序在编译时分别将指针和引⽤添加到符号表上,符号表中记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,⽽引⽤在符号表上对应的地址值为引⽤对象的地址值(与实参名字不同,地址相同)。符号表⽣成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),⽽引⽤对象则不能修改。

4、C++ 中 const 和 static 关键字(定义,⽤途)

static 作⽤:控制变量的存储⽅式和可⻅性。

作⽤⼀:修饰局部变量:⼀般情况下,对于局部变量在程序中是存放在栈区的,并且局部的⽣命周期在包含语句块执⾏结束时便结束了。但是如果⽤ static 关键字修饰的话,该变量便会存放在静态数据区,其⽣命周期会⼀直延续到整个程序执⾏结束。但是要注意的是,虽然⽤static 对局部变量进⾏修饰之后,其⽣命周期以及存储空间发⽣了变化,但其作⽤域并没有改变,作⽤域还是限制在其语句块。

作⽤⼆:修饰全部变量:对于⼀个全局变量,它既可以在本⽂件中被访问到,也可以在同⼀个⼯程中其它源⽂件被访问(添加 extern进⾏声明即可)。⽤ static 对全局变量进⾏修饰改变了其作⽤域范围,由原来的整个⼯程可⻅变成了本⽂件可⻅

static关键字修饰全局变量时,限制了它的链接属性(linkage),从外部链接(external linkage) 变为内部链接(internal linkage)。

file1.cpp

cpp
// 不加 static 的全局变量
int global_var = 100;

// 加 static 的全局变量
static int static_var = 200;

void func1() {
    // 两个变量在本文件中都可以访问
    cout << global_var << endl;  // ✅ 可以
    cout << static_var << endl;  // ✅ 可以
}

file2.cpp

cpp
// 声明外部变量
extern int global_var;  // ✅ 可以,因为 global_var 在整个工程可见
extern int static_var;  // ❌ 编译错误!static_var 只在 file1.cpp 中可见

void func2() {
    cout << global_var << endl;  // ✅ 可以访问 file1.cpp 中的 global_var
    cout << static_var << endl;  // ❌ 编译错误,找不到 static_var
}

编译结果

file2.cpp: error: 'static_var' was not declared in this scope

作⽤三:修饰函数:⽤ static 修饰函数,情况和修饰全局变量类似,也是改变了函数的作⽤域。

作⽤四:修饰类:如果 C++ 中对类中的某个函数⽤ static 修饰,则表示该函数属于⼀个类⽽不是属于此类的任何特定对象;如果对类中的某个变量进⾏ static 修饰,则表示该变量以及所有的对象所有,存储空间中只存在⼀个副本,可以通过;类和对象去调⽤。

(补充:静态⾮常量数据成员,其只能在类外定义和初始化,在类内仅是声明⽽已。)

作⽤五:类成员/类函数声明 static

  • 函数体内 static 变量的作⽤范围为该函数体,不同于 auto 变量,该变量的内存只被分配⼀次,因此其值在下次调⽤时仍维持上次的值

  • 在模块内的 static 全局变量可以被模块内所⽤函数访问,但不能被模块外其它函数访问;

  • 在模块内的 static 函数只可被这⼀模块内的其它函数调⽤,这个函数的使⽤范围被限制在声明它的模块内;

  • 在类中的 static 成员变量属于整个类所拥有,对类的所有对象只有⼀份拷⻉;

  • 在类中的 static 成员函数属于整个类所拥有,这个函数不接收 this 指针,因⽽只能访问类的 static 成员变量

  • static 类对象必须要在类外进⾏初始化,static 修饰的变量先于对象存在,所以 static 修饰的变量要在类外初始化;

  • 由于 static 修饰的类成员属于类,不属于对象,因此 static 类成员函数是没有 this 指针,this 指针是指向本对象的指针,正因为没有 this 指针,所以 static 类成员函数不能访问⾮static 的类成员,只能访问 static修饰的类成员;

  • static 成员函数不能被 virtual 修饰,static 成员不属于任何对象或实例,所以加上 virtual没有任何实际意义;静态成员函数没有 this 指针,虚函数的实现是为每⼀个对象分配⼀个vptr 指针,⽽ vptr 是通过 this 指针调⽤的,所以不能为 virtual;虚函数的调⽤关系,this->vptr->ctable->virtual function

const 关键字:含义及实现机制

const 修饰基本类型数据类型:基本数据类型,修饰符 const 可以⽤在类型说明符前,也可以⽤在类型说明符后,其结果是⼀样的。在使⽤这些常量的时候,只要不改变这些常量的值即可。

const 修饰指针变量和引⽤变量如果 const 位于⼩星星的左侧,则 const 就是⽤来修饰指针所指向的变量,即指针指向为常量;如果 const 位于⼩星星的右侧,则 const 就是修饰指针本身,即指针本身是常量。

const 应⽤到函数中:作为参数的 const 修饰符:调⽤函数的时候,⽤相应的变量初始化const 常量,则在函数体中,按照 const 所修饰的部分进⾏常量化,保护了原对象的属性。

[注意]:参数 const 通常⽤于参数为指针或引⽤的情况; 作为函数返回值的 const 修饰符:声明了返回值后,const 按照"修饰原则"进⾏修饰,起到相应的保护作⽤。

"修饰原则"指的是 const总是修饰它左边的内容(如果左边没有,就修饰右边的内容)。根据返回值类型的不同,const起到的保护作用也不同。

const 在类中的⽤法:const 成员变量,只在某个对象⽣命周期内是常量,⽽对于整个类⽽⾔是可以改变的。因为类可以创建多个对象,不同的对象其 const 数据成员值可以不同。所以不能在类的声明中初始化 const 数据成员,因为类的对象在没有创建时候,编译器不知道 const数据成员的值是什么。const 数据成员的初始化只能在类的构造函数的初始化列表中进⾏。

const 成员函数:const 成员函数的主要⽬的是防⽌成员函数修改对象的内容。要注意,const关键字和 static 关键字对于成员函数来说是不能同时使⽤的,因为 static 关键字修饰静态成员函数不含有 this 指针,即不能实例化,const 成员函数⼜必须具体到某⼀个函数。

const 修饰类对象,定义常量对象:常量对象只能调⽤常量函数,别的成员函数都不能调⽤。

补充:const 成员函数中如果实在想修改某个变量,可以使⽤ mutable 进⾏修饰。成员变量中如果想建⽴在整个类中都恒定的常量,应该⽤类中的枚举常量来实现或者 static const。

C ++ 中的 const类成员函数(⽤法和意义)

常量对象可以调⽤类中的 const 成员函数,但不能调⽤⾮ const 成员函数;(原因:对象调⽤成员函数时,在形参列表的最前⾯加⼀个形参 this,但这是隐式的。this 指针是默认指向调⽤函数的当前对象的,所以,很⾃然,this 是⼀个常量指针 test * const,因为不可以修改this 指针代表的地址。但当成员函数的参数列表(即⼩括号)后加了 const 关键字(void print() const;),此成员函数为常量成员函数,此时它的隐式this形参为 const test * const,即不可以通过 this 指针来改变指向对象的值。

⾮常量对象可以调⽤类中的 const 成员函数,也可以调⽤⾮ const 成员函数。

5、C 和 C++ 区别(函数/类/struct/class)

⾸先,C 和 C++ 在基本语句上没有过⼤的区别。

C++ 有新增的语法和关键字,语法的区别有头⽂件的不同和命名空间的不同,C++ 允许我们⾃⼰定义⾃⼰的空间,C 中不可以。关键字⽅⾯⽐如 C++ 与 C 动态管理内存的⽅式不同,C++ 中在 malloc 和 free 的基础上增加了 new 和 delete,⽽且 C++ 中在指针的基础上增加了引⽤的概念,关键字例如 C++中还增加了 auto,explicit 体现显示和隐式转换上的概念要求,还有 dynamic_cast 增加类型安全⽅⾯的内容。

函数⽅⾯ C++ 中有重载和虚函数的概念:C++ ⽀持函数重载⽽ C 不⽀持,是因为 C++ 函数的名字修饰与 C 不同,C++ 函数名字的修饰会将参数加在后⾯,例如,int func(int,double)经过名字修饰之后会变成_func_int_double,⽽ C 中则会变成 _func,所以 C++ 中会⽀持不同参数调⽤不同函数。

C++ 还有虚函数概念,⽤以实现多态。

类⽅⾯,C 的 struct 和 C++ 的类也有很⼤不同:C++ 中的 struct 不仅可以有成员变量还可以成员函数,⽽且对于 struct 增加了权限访问的概念,struct 的默认成员访问权限和默认继承权限都是 public,C++ 中除了 struct 还有 class 表示类,struct 和 class 还有⼀点不同在于 class 的默认成员访问权限和默认继承权限都是 private。

C++ 中增加了模板还重⽤代码,提供了更加强⼤的 STL 标准库。

最后补充⼀点就是 C 是⼀种结构化的语⾔,重点在于算法和数据结构。C 程序的设计⾸先考虑的是如何通过⼀个代码,⼀个过程对输⼊进⾏运算处理输出。⽽ C++ ⾸先考虑的是如何构造⼀个对象模型,让这个模型能够契合与之对应的问题领域,这样就能通过获取对象的状态信息得到输出。

C 的 struct 更适合看成是⼀个数据结构的实现体,⽽ C++ 的 class 更适合看成是⼀个对象的实现体。

6、C++ 和 Java 区别(语⾔特性,垃圾回收,应⽤场景等)

指针:Java 语⾔让程序员没法找到指针来直接访问内存,没有指针的概念,并有内存的⾃动管理功能,从⽽有效的防⽌了 C++ 语⾔中的指针操作失误的影响。但并⾮ Java 中没有指针,Java 虚拟机内部中还是⽤了指针,保证了 Java 程序的安全。

多重继承C++ ⽀持多重继承但 Java 不⽀持,但⽀持⼀个类继承多个接⼝,实现 C++ 中多重继承的功能,⼜避免了 C++ 的多重继承带来的不便。

数据类型和类:Java 是完全⾯向对象的语⾔,所有的函数和变量必须是类的⼀部分。除了基本数据类型之外,其余的都作为类对象,对象将数据和⽅法结合起来,把它们封装在类中,这样每个对象都可以实现⾃⼰的特点和⾏为。Java 中取消了 C++ 中的 struct 和 union 。

⾃动内存管理:Java 程序中所有对象都是⽤ new 操作符建⽴在内存堆栈上,Java ⾃动进⾏⽆⽤内存回收操作,不需要程序员进⾏⼿动删除。⽽ C++ 中必须由程序员释放内存资源,增加了程序设计者的负担。Java 中当⼀个对象不再被⽤到时,⽆⽤内存回收器将给他们加上标签。Java ⾥⽆⽤内存回收程序是以线程⽅式在后台运⾏的,利⽤空闲时间⼯作来删除。

Java 不⽀持操作符重载。操作符重载被认为是 C++ 的突出特性。

Java 不⽀持预处理功能。C++ 在编译过程中都有⼀个预编译阶段,Java 没有预处理器,但它提供了 import 与 C++ 预处理器具有类似功能。

类型转换:C++ 中有数据类型隐含转换的机制,Java 中需要限时强制类型转换。

字符串:C++中字符串是以 Null 终⽌符代表字符串的结束,⽽ Java 的字符串是⽤类对象(string 和 stringBuffer)来实现的。

Java 中不提供 goto 语句,虽然指定 goto 作为关键字,但不⽀持它的使⽤,使程序简洁易读。

Java 的异常机制⽤于捕获例外事件,增强系统容错能⼒。

7、说⼀下 C++ ⾥是怎么定义常量的?常量存放在内存的哪个位置?

对于局部常量,存放在栈区;

对于全局常量,编译期⼀般不分配内存,放在符号表中以提⾼访问效率;

字⾯值常量,⽐如字符串,放在常量区。

8、C++ 中重载和重写,重定义的区别

重载翻译⾃ overload,是指同⼀可访问区内被声明的⼏个具有不同参数列表的同名函数,依赖于C++函数名字的修饰会将参数加在后⾯,可以是参数类型,个数,顺序的不同。根据参数列表决定调⽤哪个函数,重载不关⼼函数的返回类型。

重写翻译⾃ override,派⽣类中重新定义⽗类中除了函数体外完全相同的虚函数,注意被重写的函数不能是 static 的,⼀定要是虚函数,且其他⼀定要完全相同。要注意,重写和被重写的函数是在不同的类当中的,重写函数的访问修饰符是可以不同的,尽管 virtual 中是 private 的,派⽣类中重写可以改为 public

重定义(隐藏)派⽣类重新定义⽗类中相同名字的⾮ virtual 函数,参数列表和返回类型都可以不同,即⽗类中除了定义成 virtual 且完全相同的同名函数才不会被派⽣类中的同名函数所隐藏(重定义)。

9、介绍 C++ 所有的构造函数

类的对象被创建时,编译系统为对象分配内存空间,并⾃动调⽤构造函数,由构造函数完成成员的初始化⼯作。

即构造函数的作⽤:初始化对象的数据成员。

⽆参数构造函数:即默认构造函数,如果没有明确写出⽆参数构造函数,编译器会⾃动⽣成默认的⽆参数构造函数,函数为空,什么也不做,如果不想使⽤⾃动⽣成的⽆参构造函数,必需要⾃⼰显示写出⼀个⽆参构造函数。

⼀般构造函数:也称重载构造函数,⼀般构造函数可以有各种参数形式,⼀个类可以有多个⼀般构造函数,前提是参数的个数或者类型不同,创建对象时根据传⼊参数不同调⽤不同的构造函数。

拷⻉构造函数:拷⻉构造函数的函数参数为对象本身的引⽤,⽤于根据⼀个已存在的对象复制出⼀个新的该类的对象,⼀般在函数中会将已存在的对象的数据成员的值⼀⼀复制到新创建的对象中。如果没有显示的写拷⻉构造函数,则系统会默认创建⼀个拷⻉构造函数,但当类中有指针成员时,最好不要使⽤编译器提供的默认的拷⻉构造函数,最好⾃⼰定义并且在函数中执⾏深拷⻉。

类型转换构造函数:根据⼀个指定类型的对象创建⼀个本类的对象,也可以算是⼀般构造函数的⼀种,这⾥提出来,是想说有的时候不允许默认转换的话,要记得将其声明为 explict 的,来阻⽌⼀些隐式转换的发⽣。

赋值运算符的重载:注意,这个类似拷⻉构造函数,将=右边的本类对象的值复制给=左边的对象,它不属于构造函数,=左右两边的对象必需已经被创建。如果没有显示的写赋值运算符的重载,系统也会⽣成默认的赋值运算符,做⼀些基本的拷⻉⼯作。

这⾥区分

A a1, A a2; a1 = a2;//调⽤赋值运算符A 
a3 = a1;//调⽤拷⻉构造函数,因为进⾏的是初始化⼯作,a3 并未存在

10、C++ 的四种强制转换

C++ 的四种强制转换包括:static_cast, dynamic_cast, const_cast, reinterpret_cast

  • static_cast:明确指出类型转换,⼀般建议将隐式转换都替换成显示转换,因为没有动态类型检查,上⾏转换(派⽣类->基类)安全,下⾏转换(基类->派⽣类)不安全,所以主要执⾏⾮多态的转换操作;
  • dynamic_cast:专⻔⽤于派⽣类之间的转换,type-id 必须是类指针,类引⽤或 void*,对于下⾏转换是安全的当类型不⼀致时,转换过来的是空指针,⽽static_cast,当类型不⼀致时,转换过来的事错误意义的指针,可能造成⾮法访问等问题。
  • const_cast:专⻔⽤于 const 属性的转换,去除 const 性质,或增加 const 性质,是四个转换符中唯⼀⼀个可以操作常量的转换符。
  • reinterpret_cast:不到万不得已,不要使⽤这个转换符,⾼危操作。使⽤特点:从底层对数据进⾏重新解释,依赖具体的平台,可移植性差;可以将整形转换为指针,也可以把指针转换为数组;可以在指针和引⽤之间进⾏肆⽆忌惮的转换。

11、指针和引⽤的区别

指针和引⽤都是⼀种内存地址的概念,区别呢,指针是⼀个实体,引⽤只是⼀个别名。

在程序编译的时候,将指针和引⽤添加到符号表中。

指针它指向⼀块内存,指针的内容是所指向的内存的地址,在编译的时候,则是将“指针变量名-指针变量的地址”添加到符号表中,所以说,指针包含的内容是可以改变的,允许拷⻉和赋值,有 const 和⾮ const 区别,甚⾄可以为空,sizeof 指针得到的是指针类型的⼤⼩。

⽽对于引⽤来说,它只是⼀块内存的别名,在添加到符号表的时候,是将"引⽤变量名-引⽤对象的地址"添加到符号表中,符号表⼀经完成不能改变,所以引⽤必须⽽且只能在定义时被绑定到⼀块内存上,后续不能更改,也不能为空,也没有 const 和⾮ const 区别。

sizeof 引⽤得到代表对象的⼤⼩。⽽ sizeof 指针得到的是指针本身的⼤⼩。另外在参数传递中,指针需要被解引⽤后才可以对对象进⾏操作,⽽直接对引⽤进⾏的修改会直接作⽤到引⽤对象上。

作为参数时也不同,传指针的实质是传值,传递的值是指针的地址;传引⽤的实质是传地址,传递的是变量的地址。

12、野(wild)指针与悬空(dangling)指针有什么区别?如何避免?

野指针(wild pointer):就是没有被初始化过的指针。⽤gcc -Wall编译, 会出现useduninitialized警告。

悬空指针:是指针最初指向的内存已经被释放了的⼀种指针。

⽆论是野指针还是悬空指针,都是指向⽆效内存区域(这⾥的⽆效指的是"不安全不可控")的指针。访问"不安全可控"(invalid)的内存区域将导致"Undefined Behavior"。

如何避免使⽤野指针?在平时的编码中,养成在定义指针后且在使⽤之前完成初始化的习惯或者使⽤智能指针。

13、说⼀下 const 修饰指针如何区分?

下⾯都是合法的声明,但是含义⼤不同:

  • const int * p1; //指向整形常量的指针,它指向的值不能修改

  • int * const p2; //指向整形的常量指针,它不能在指向别的变量,但指向(变量)的值可以修改。

  • const int *const p3; //指向整形常量的常量指针。它既不能再指向别的常量,指向的值也不能修改。

理解这些声明的技巧在于,查看关键字const右边来确定什么被声明为常量,如果该关键字的右边是类型,则值是常量;如果关键字的右边是指针变量,则指针本身是常量。

14、简单说⼀下函数指针

从定义和⽤途两⽅⾯来说⼀下⾃⼰的理解:

⾸先是定义:函数指针是指向函数的指针变量。函数指针本身⾸先是⼀个指针变量,该指针变量指向⼀个具体的函数。这正如⽤指针变量可指向整型变量、字符型、数组⼀样,这⾥是指向函数。

在编译时,每⼀个函数都有⼀个⼊⼝地址,该⼊⼝地址就是函数指针所指向的地址。有了指向函数的指针变量后,可⽤该指针变量调⽤函数,就如同⽤指针变量可引⽤其他类型变量⼀样,在这些概念上是⼤体⼀致的。

其次是⽤途:调⽤函数和做函数的参数,⽐如回调函数。

示例:

c
char* fun(char* p) {…} // 函数fun
char* (*pf)(char* p); // 函数指针pf
pf = fun; // 函数指针pf指向函数fun
pf(p); // 通过函数指针pf调⽤函数fun

15、堆和栈区别

由编译器进⾏管理,在需要时由编译器⾃动分配空间,在不需要时候⾃动回收空间,⼀般保存的是局部变量和函数参数等。

连续的内存空间,在函数调⽤的时候,⾸先⼊栈的主函数的下⼀条可执⾏指令的地址,然后是函数的各个参数。

⼤多数编译器中,参数是从右向左⼊栈(原因在于采⽤这种顺序,是为了让程序员在使⽤C/C++的“函数参数⻓度可变”这个特性时更⽅便。如果是从左向右压栈,第⼀个参数(即描述可变参数表各变量类型的那个参数)将被放在栈底,由于可变参的函数第⼀步就需要解析可变参数表的各参数类型,即第⼀步就需要得到上述参数,因此,将它放在栈底是很不⽅便的。)本次函数调⽤结束时,局部变量先出栈,然后是参数,最后是栈顶指针最开始存放的地址,程序由该点继续运⾏,不会产⽣碎⽚。

栈是⾼地址向低地址扩展,栈低⾼地址,空间较⼩。

由程序员管理,需要⼿动 new malloc delete free 进⾏分配和回收,如果不进⾏回收的话,会造成内存泄漏的问题。

不连续的空间,实际上系统中有⼀个空闲链表,当有程序申请的时候,系统遍历空闲链表找到第⼀个⼤于等于申请⼤⼩的空间分配给程序,⼀般在分配程序的时候,也会空间头部写⼊内存⼤⼩,⽅便 delete 回收空间⼤⼩。当然如果有剩余的,也会将剩余的插⼊到空闲链表中,这也是产⽣内存碎⽚的原因。

堆是低地址向⾼地址扩展,空间交⼤,较为灵活。

16、函数传递参数的⼏种⽅式

值传递:形参是实参的拷⻉,函数内部对形参的操作并不会影响到外部的实参。

指针传递:也是值传递的⼀种⽅式,形参是指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进⾏操作。

引⽤传递:实际上就是把引⽤对象的地址放在了开辟的栈空间中,函数内部对形参的任何操作可以直接映射到外部的实参上⾯。

17、new / delete ,malloc / free 区别

都可以⽤来在堆上分配和回收空间。new /delete 是操作符,malloc/free 是库函数。

执⾏ new 实际上执⾏两个过程:1.分配未初始化的内存空间(malloc);2.使⽤对象的构造函数对空间进⾏初始化;返回空间的⾸地址。如果在第⼀步分配空间中出现问题,则抛出std::bad_alloc 异常,或被某个设定的异常处理函数捕获处理;如果在第⼆步构造对象时出现异常,则⾃动调⽤ delete 释放内存。

执⾏ delete 实际上也有两个过程:1. 使⽤析构函数对对象进⾏析构;2.回收内存空间(free)。

以上也可以看出 new 和 malloc 的区别,new 得到的是经过初始化的空间,⽽ malloc 得到的是未初始化的空间。所以 new 是 new ⼀个类型,⽽ malloc 则是malloc ⼀个字节⻓度的空间。delete 和 free 同理,delete 不仅释放空间还析构对象,delete ⼀个类型,free ⼀个字节⻓度的空间。

为什么有了 malloc/free 还需要 new/delete?因为对于⾮内部数据类型⽽⾔,光⽤ malloc/free ⽆法满⾜动态对象的要求。对象在创建的同时需要⾃动执⾏构造函数,对象在消亡以前要⾃动执⾏析构函数。由于 mallo/free 是库函数⽽不是运算符,不在编译器控制权限之内,不能够把执⾏的构造函数和析构函数的任务强加于 malloc/free,所以有了 new/delete 操作符。

18、volatile 和 extern 关键字

volatile 三个特性

易变性:在汇编层⾯反映出来,就是两条语句,下⼀条语句不会直接使⽤上⼀条语句对应的volatile 变量的寄存器内容,⽽是重新从内存中读取

不可优化性:volatile 告诉编译器,不要对我这个变量进⾏各种激进的优化,甚⾄将变量直接消除,保证程序员写在代码中的指令,⼀定会被执⾏。

顺序性:能够保证 volatile 变量之间的顺序性,编译器不会进⾏乱序优化。

extern

在 C 语⾔中,修饰符 extern ⽤在变量或者函数的声明前,⽤来说明 “此变量/函数是在别处定义的,要在此处引⽤”。

注意 extern 声明的位置对其作⽤域也有关系,如果是在 main 函数中进⾏声明的,则只能在main 函数中调⽤,在其它函数中不能调⽤。其实要调⽤其它⽂件中的函数和变量,只需把该⽂件⽤ #include 包含进来即可,为啥要⽤ extern?因为⽤ extern 会加速程序的编译过程,这样能节省时间。

在 C++ 中 extern 还有另外⼀种作⽤,⽤于指示 C 或者 C++函数的调⽤规范。⽐如在 C++中调⽤ C 库函数,就需要在 C++程序中⽤ extern “C” 声明要引⽤的函数。这是给链接器⽤的,告诉链接器在链接的时候⽤C 函数规范来链接。主要原因是 C++和 C 程序编译完成后在⽬标代码中命名规则不同,⽤此来解决名字匹配的问题。

19、define 和 const 区别(编译阶段、安全性、内存占⽤等)

对于 define 来说,宏定义实际上是在预编译阶段进⾏处理,没有类型,也就没有类型检查,仅仅做的是遇到宏定义进⾏字符串的展开,遇到多少次就展开多少次,⽽且这个简单的展开过程中,很容易出现边界效应,达不到预期的效果。因为 define 宏定义仅仅是展开,因此运⾏时系统并不为宏定义分配内存,但是从汇编的⻆度来讲,define 却以⽴即数的⽅式保留了多份数据的拷⻉。

对于 const 来说,const 是在编译期间进⾏处理的,const 有类型,也有类型检查,程序运⾏时系统会为 const 常量分配内存,⽽且从汇编的⻆度讲,const 常量在出现的地⽅保留的是真正数据的内存地址,只保留了⼀份数据的拷⻉,省去了不必要的内存空间。⽽且,有时编译器不会为普通的 const 常量分配内存,⽽是直接将 const 常量添加到符号表中,省去了读取和写⼊内存的操作,效率更⾼。

20、计算下⾯⼏个类的⼤⼩

cpp
classA{}; sizeof(A) = 1; //空类在实例化时得到⼀个独⼀⽆⼆的地址,所以为 1.

classA{virtual Fun(){} }; sizeof(A) = 4(32bit)/8(64bit) //当 C++ 类中有虚函数的时候,会有⼀个指向虚函数表的指针(vptr)
classA{static int a; }; sizeof(A) = 1;
classA{int a; }; sizeof(A) = 4;
classA{static int a; int b; }; sizeof(A) = 4;

21、⾯向对象的三⼤特性,并举例说明

C++ ⾯向对象的三⼤特征是:封装、继承、多态。

所谓封装

就是把客观事物封装成抽象的类,并且类可以把⾃⼰的数据和⽅法只让信任的类或者对象操作,对不可信的进⾏信息隐藏。⼀个类就是⼀个封装了数据以及操作这些数据的代码的逻辑实体。在⼀个对象内部,某些代码或某些数据可以是私有的,不能被外界访问。通过这种⽅式,对象对内部数据提供了不同级别的保护,以防⽌程序中⽆关的部分意外的改变或错误的使⽤了对象的私有部分。

所谓继承

是指可以让某个类型的对象获得另⼀个类型的对象的属性的⽅法。它⽀持按级分类的概念。继承是指这样⼀种能⼒:它可以使⽤现有类的所有功能,并在⽆需重新编写原来的类的情况下对这些功能进⾏扩展。通过继承创建的新类称为“⼦类”或者“派⽣类”,被继承的类称为“基类”、“⽗类”或“超类”。继承的过程,就是从⼀般到特殊的过程。要实现继承,可以通过“继承”和“组合”来实现。

继承概念的实现⽅式有两类:

实现继承:实现继承是指直接使⽤基类的属性和⽅法⽽⽆需额外编码的能⼒。

接⼝继承:接⼝继承是指仅使⽤属性和⽅法的名称、但是⼦类必需提供实现的能⼒。

所谓多态

就是向不同的对象发送同⼀个消息,不同对象在接收时会产⽣不同的⾏为(即⽅法)。即⼀个接⼝,可以实现多种⽅法。

多态与⾮多态的实质区别就是函数地址是早绑定还是晚绑定的。如果函数的调⽤,在编译器编译期间就可以确定函数的调⽤地址,并产⽣代码,则是静态的,即地址早绑定。⽽如果函数调⽤的地址不能在编译器期间确定,需要在运⾏时才确定,这就属于晚绑定。

22、多态的实现

多态其实⼀般就是指继承加虚函数实现的多态,对于重载来说,实际上基于的原理是,编译器为函数⽣成符号表时的不同规则,重载只是⼀种语⾔特性,与多态⽆关,与⾯向对象也⽆关,但这⼜是 C++中增加的新规则,所以也算属于 C++,所以如果⾮要说重载算是多态的⼀种,那就可以说:多态可以分为静态多态和动态多态。

静态多态其实就是重载,因为静态多态是指在编译时期就决定了调⽤哪个函数,根据参数列表来决定;

动态多态是指通过⼦类重写⽗类的虚函数来实现的,因为是在运⾏期间决定调⽤的函数,所以称为动态多态,⼀般情况下我们不区分这两个时所说的多态就是指动态多态。

动态多态的实现与虚函数表,虚函数指针相关。

扩展:⼦类是否要重写⽗类的虚函数?⼦类继承⽗类时,⽗类的纯虚函数必须重写,否则⼦类也是⼀个虚类不可实例化。定义纯虚函数是为了实现⼀个接⼝,起到⼀个规范的作⽤,规范继承这个类的程序员必须实现这个函数。

23、虚函数相关(虚函数表,虚函数指针),虚函数的实现原理

⾸先我们来说⼀下,C++中多态的表象,在基类的函数前加上 virtual 关键字,在派⽣类中重写该函数,运⾏时将会根据对象的实际类型来调⽤相应的函数。如果对象类型是派⽣类,就调⽤派⽣类的函数,如果是基类,就调⽤基类的函数。

实际上,当⼀个类中包含虚函数时,编译器会为该类⽣成⼀个虚函数表,保存该类中虚函数的地址,同样,派⽣类继承基类,派⽣类中⾃然⼀定有虚函数,所以编译器也会为派⽣类⽣成⾃⼰的虚函数表。当我们定义⼀个派⽣类对象时,编译器检测该类型有虚函数,所以为这个派⽣类对象⽣成⼀个虚函数指针,指向该类型的虚函数表,这个虚函数指针的初始化是在构造函数中完成的。

后续如果有⼀个基类类型的指针,指向派⽣类,那么当调⽤虚函数时,就会根据所指真正对象的虚函数表指针去寻找虚函数的地址,也就可以调⽤派⽣类的虚函数表中的虚函数以此实现多态。

补充:如果基类中没有定义成 virtual,那么进⾏ Base B; Derived D; Base *p = D; p->function(); 这种情况下调⽤的则是 Base 中的 function()。因为基类和派⽣类中都没有虚函数的定义,那么编译器就会认为不⽤留给动态多态的机会,就事先进⾏函数地址的绑定(早绑定),详述过程就是,定义了⼀个派⽣类对象,⾸先要构造基类的空间,然后构造派⽣类的⾃身内容,形成⼀个派⽣类对象,那么在进⾏类型转换时,直接截取基类的部分的内存,编译器认为类型就是基类,那么(函数符号表[不同于虚函数表的另⼀个表]中)绑定的函数地址也就是基类中函数的地址,所以执⾏的是基类的函数。

24、编译器处理虚函数表应该如何处理

对于派⽣类来说,编译器建⽴虚函数表的过程其实⼀共是三个步骤:

  • 拷⻉基类的虚函数表,如果是多继承,就拷⻉每个有虚函数基类的虚函数表
  • 当然还有⼀个基类的虚函数表和派⽣类⾃身的虚函数表共⽤了⼀个虚函数表,也称为某个基类为派⽣类的主基类
  • 查看派⽣类中是否有重写基类中的虚函数,如果有,就替换成已经重写的虚函数地址;查看派⽣类是否有⾃身的虚函数,如果有,就追加⾃身的虚函数到⾃身的虚函数表中。

Derived *pd = new D(); B *pb = pd; C *pc = pd; 其中 pb,pd,pc 的指针位置是不同的,要注意的是派⽣类的⾃身的内容要追加在主基类的内存块后。

C++ 语⾔基础篇 图 22

25、析构函数⼀般写成虚函数的原因

直观的讲:是为了降低内存泄漏的可能性。举例来说就是,⼀个基类的指针指向⼀个派⽣类的对象,在使⽤完毕准备销毁时,如果基类的析构函数没有定义成虚函数,那么编译器根据指针类型就会认为当前对象的类型是基类,调⽤基类的析构函数(该对象的析构函数的函数地址早就被绑定为基类的析构函数),仅执⾏基类的析构,派⽣类的⾃身内容将⽆法被析构,造成内存泄漏。

如果基类的析构函数定义成虚函数,那么编译器就可以根据实际对象,执⾏派⽣类的析构函数,再执⾏基类的析构函数,成功释放内存。

26、构造函数为什么⼀般不定义为虚函数

虚函数调⽤只需要知道“部分的”信息,即只需要知道函数接⼝,⽽不需要知道对象的具体类型。但是,我们要创建⼀个对象的话,是需要知道对象的完整信息的。特别是,需要知道要创建对象的确切类型,因此,构造函数不应该被定义成虚函数;

⽽且从⽬前编译器实现虚函数进⾏多态的⽅式来看,虚函数的调⽤是通过实例化之后对象的虚函数表指针来找到虚函数的地址进⾏调⽤的,如果说构造函数是虚的,那么虚函数表指针则是不存在的,⽆法找到对应的虚函数表来调⽤虚函数,那么这个调⽤实际上也是违反了先实例化后调⽤的准则。

27、构造函数或析构函数中调⽤虚函数会怎样

实际上是不应该在构造函数或析构函数中调⽤虚函数的,因为这样的调⽤其实并不会带来所想要的效果。

举例来说就是,有⼀个动物的基类,基类中定义了⼀个动物本身⾏为的虚函数 action_type(),在基类的构造函数中调⽤了这个虚函数。

派⽣类中重写了这个虚函数,我们期望着根据对象的真实类型不同,⽽调⽤各⾃实现的虚函数,但实际上当我们创建⼀个派⽣类对象时,⾸先会创建派⽣类的基类部分,执⾏基类的构造函数,此时,派⽣类的⾃身部分还没有被初始化,对于这种还没有初始化的东⻄,C++选择当它们还不存在作为⼀种安全的⽅法。

也就是说构造派⽣类的基类部分是,编译器会认为这就是⼀个基类类型的对象,然后调⽤基类类型中的虚函数实现,并没有按照我们想要的⽅式进⾏。即对象在派⽣类构造函数执⾏前并不会成为⼀个派⽣类对象。

在析构函数中也是同理,派⽣类执⾏了析构函数后,派⽣类的⾃身成员呈现未定义的状态,那么在执⾏基类的析构函数中是不可能调⽤到派⽣类重写的⽅法的。所以说,我们不应该在构在函数或析构函数中调⽤虚函数,就算调⽤⼀般也不会达到我们想要的结果。

28、析构函数的作⽤,如何起作⽤?

构造函数只是起初始化值的作⽤,但实例化⼀个对象的时候,可以通过实例去传递参数,从主函数传递到其他的函数⾥⾯,这样就使其他的函数⾥⾯有值了。规则,只要你⼀实例化对象,系统⾃动回调⽤⼀个构造函数,就是你不写,编译器也⾃动调⽤⼀次。

析构函数与构造函数的作⽤相反,⽤于撤销对象的⼀些特殊任务处理,可以是释放对象分配的内存空间;特点:析构函数与构造函数同名,但该函数前⾯加~。

析构函数没有参数,也没有返回值,⽽且不能重载,在⼀个类中只能有⼀个析构函数。当撤销对象时,编译器也会⾃动调⽤析构函数。每⼀个类必须有⼀个析构函数,⽤户可以⾃定义析构函数,也可以是编译器⾃动⽣成默认的析构函数。⼀般析构函数定义为类的公有成员。

29、构造函数的执⾏顺序?析构函数的执⾏顺序?

构造函数顺序

  • 基类构造函数。如果有多个基类,则构造函数的调⽤顺序是某类在类派⽣表中出现的顺序,⽽不是它们在成员初始化表中的顺序。
  • 成员类对象构造函数。如果有多个成员类对象则构造函数的调⽤顺序是对象在类中被声明的顺序,⽽不是它们出现在成员初始化表中的顺序。
  • 派⽣类构造函数。

析构函数顺序

  • 调⽤派⽣类的析构函数;

  • 调⽤成员类对象的析构函数;

  • 调⽤基类的析构函数。

30、纯虚函数(应⽤于接⼝继承和实现继承)

实际上,纯虚函数的出现就是为了让继承可以出现多种情况:

  • 有时我们希望派⽣类只继承成员函数的接⼝
  • 有时我们⼜希望派⽣类既继承成员函数的接⼝,⼜继承成员函数的实现,⽽且可以在派⽣类中可以重写成员函数以实现多态
  • 有的时候我们⼜希望派⽣类在继承成员函数接⼝和实现的情况下,不能重写缺省的实现。

其实,声明⼀个纯虚函数的⽬的就是为了让派⽣类只继承函数的接⼝,⽽且派⽣类中必需提供⼀个这个纯虚函数的实现,否则含有纯虚函数的类将是抽象类,不能进⾏实例化。

对于纯虚函数来说,我们其实是可以给它提供实现代码的,但是由于抽象类不能实例化,调⽤这个实现的唯⼀⽅式是在派⽣类对象中指出其 class 名称来调⽤(通过类名限定调用)。

31、静态绑定和动态绑定的介绍

说起静态绑定和动态绑定,我们⾸先要知道静态类型和动态类型,静态类型就是它在程序中被声明时所采⽤的类型,在编译期间确定。动态类型则是指“⽬前所指对象的实际类型”,在运⾏期间确定。

静态绑定,⼜名早绑定,绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发⽣在编译期间

动态绑定,⼜名晚绑定,绑定的是动态类型,所对应的函数或属性依赖于动态类型,发⽣在运⾏期间。

⽐如说,virtual 函数是动态绑定的,⾮虚函数是静态绑定的,缺省参数值也是静态绑定的。这⾥呢,就需要注意,我们不应该重新定义继承⽽来的缺省参数,因为即使我们重定义了,也不会起到效果。

虚函数的缺省参数值是静态绑定的(编译时确定),而不是动态绑定的(运行时确定)。即使派生类重新定义了缺省参数值,通过基类指针或引用调用时,使用的仍然是基类定义的缺省参数,而不是派生类的

因为⼀个基类的指针指向⼀个派⽣类对象,在派⽣类的对象中针对虚函数的参数缺省值进⾏了重定义,但是缺省参数值是静态绑定的,静态绑定绑定的是静态类型相关的内容,所以会出现⼀种派⽣类的虚函数实现⽅式结合了基类的缺省参数值的调⽤效果,这个与所期望的效果不同。

c
#include <iostream>
using namespace std;

class Base {
public:
    virtual void print(int x = 10) {
        cout << "Base::print, x = " << x << endl;
    }
};

class Derived : public Base {
public:
    void print(int x = 20) override {  // 重新定义了缺省参数!
        cout << "Derived::print, x = " << x << endl;
    }
};

int main() {
    Derived d;
    Base* bp = &d;
    Base& br = d;
    
    bp->print();  // 输出:Derived::print, x = 10  ← 用的是基类的缺省参数!
    br.print();   // 输出:Derived::print, x = 10  ← 同样
    
    d.print();    // 输出:Derived::print, x = 20  ← 通过派生类对象调用,才用自己的缺省参数
    
    return 0;
}

32、深拷⻉和浅拷⻉的区别(举例说明深拷⻉的安全性)

当出现类的等号赋值时,会调⽤拷⻉函数,在未定义显示拷⻉构造函数的情况下,系统会调⽤默认的拷⻉函数-即浅拷⻉,它能够完成成员的⼀⼀复制。当数据成员中没有指针时,浅拷⻉是可⾏的。

但当数据成员中有指针时,如果采⽤简单的浅拷⻉,则两类中的两个指针指向同⼀个地址,当对象快要结束时,会调⽤两次析构函数,⽽导致指野指针的问题。

所以,这时必需采⽤深拷⻉。深拷⻉与浅拷⻉之间的区别就在于深拷⻉会在堆内存中另外申请空间来存储数据,从⽽也就解决来野指针的问题。简⽽⾔之,当数据成员中有指针时,必需要⽤深拷⻉更加安全。

33、什么情况下会调⽤拷⻉构造函数(三种情况)

类的对象需要拷⻉时,拷⻉构造函数将会被调⽤,以下的情况都会调⽤拷⻉构造函数:

  • ⼀个对象以值传递的⽅式传⼊函数体,需要拷⻉构造函数创建⼀个临时对象压⼊到栈空间中

  • ⼀个对象以值传递的⽅式从函数返回,需要执⾏拷⻉构造函数创建⼀个临时对象作为返回值。

  • ⼀个对象需要通过另外⼀个对象进⾏初始化。

34、为什么拷⻉构造函数必需时引⽤传递,不能是值传递?

为了防⽌递归调⽤。当⼀个对象需要以值⽅式进⾏传递时,编译器会⽣成代码调⽤它的拷⻉构造函数⽣成⼀个副本,如果类 A 的拷⻉构造函数的参数不是引⽤传递,⽽是采⽤值传递,那么就⼜需要为了创建传递给拷⻉构造函数的参数的临时对象,⽽⼜⼀次调⽤类 A 的拷⻉构造函数,这就是⼀个⽆限递归。

35、结构体内存对⻬⽅式和为什么要进⾏内存对⻬?

⾸先我们来说⼀下结构体中内存对⻬的规则:

  • 对于结构体中的各个成员,第⼀个成员位于偏移为 0 的位置,以后的每个数据成员的偏移量必须是 min(#pragma pack() 制定的数,数据成员本身⻓度) 的倍数。

  • 在所有的数据成员完成各⾃对⻬之后,结构体或联合体本身也要进⾏对⻬,整体⻓度是min(#pragma pack()制定的数,⻓度最⻓的数据成员的⻓度) 的倍数。

#pragma pack()是 C/C++ 中的一个编译指令,用来控制结构体、联合体等复合类型的内存对齐方式。

c
#pragma pack(n)    // 设置对齐值为 n 字节(n = 1, 2, 4, 8, 16)
#pragma pack()     // 恢复为编译器默认对齐
#pragma pack(push, n)  // 保存当前对齐设置,然后设为 n
#pragma pack(pop)      // 恢复最近一次保存的对齐设置

那么内存对⻬的作⽤是什么呢?

经过内存对⻬之后,CPU 的内存访问速度⼤⼤提升。因为 CPU 把内存当成是⼀块⼀块的,块的⼤⼩可以是 2,4,8,16 个字节,因此 CPU 在读取内存的时候是⼀块⼀块进⾏读取的,块的⼤⼩称为内存读取粒度。⽐如说 CPU 要读取⼀个 4 个字节的数据到寄存器中(假设内存读取粒度是 4),如果数据是从 0 字节开始的,那么直接将 0-3 四个字节完全读取到寄存器中进⾏处理即可。

如果数据是从 1 字节开始的,就⾸先要将前 4 个字节读取到寄存器,并再次读取 4-7 个字节数据进⼊寄存器,接着把 0 字节,5,6,7 字节的数据剔除,最后合并 1,2,3,4字节的数据进⼊寄存器,所以说,当内存没有对⻬时,寄存器进⾏了很多额外的操作,⼤⼤降低了 CPU 的性能。

另外,还有⼀个就是,有的 CPU 遇到未进⾏内存对⻬的处理直接拒绝处理,不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。所以内存对⻬还有利于平台移植。

36、内存泄漏的定义,如何检测与避免?

定义:内存泄漏简单的说就是申请了⼀块内存空间,使⽤完毕后没有释放掉。它的⼀般表现⽅式是程序运⾏时间越⻓,占⽤内存越多,最终⽤尽全部内存,整个系统崩溃。由程序申请的⼀块内存,且没有任何⼀个指针指向它,那么这块内存就泄漏了。

如何检测内存泄漏

⾸先可以通过观察猜测是否可能发⽣内存泄漏,Linux 中使⽤ swap 命令观察还有多少可⽤的交换空间,在⼀两分钟内键⼊该命令三到四次,看看可⽤的交换区是否在减少。

关键特征:Swap 持续减少 + RSS(常驻内存)持续增长 + 进程数不变。

查看 Swap 使用情况:

bash
free -h
total    used    free   shared  buff/cache  available
Mem:           16G    8.2G    1.1G    200M     6.7G       6.5G
Swap:          8G     1.3G    6.7G Swap 总量 / 已用 / 剩余

查看进程 RSS 的方法:

RSS = 进程当前驻留在物理内存(RAM)中的页数 RSS ≈ 该进程"真实占用"的物理内存(但不完全是,因共享库会被多个进程分摊)

ps aux
USER   PID  %CPU %MEM   VSZ   RSS   TTY  STAT START   COMMAND
root  1234  2.0  5.2  512000 84000 pts/0 S    10:00   ./app
  • RSS:单位 KB
  • 上面例子:进程占用约 84 MB 物理内存

只看指定进程:

bash
ps -p 1234 -o pid,rss,vsz,cmd

还可以使⽤其他⼀些 /usr/bin/stat ⼯具如 netstat、vmstat 等。如发现波段有内存被分配且从不释放,⼀个可能的解释就是有个进程出现了内存泄漏。

vmstat本身不能直接告诉你是否有内存泄漏,但它提供的指标可以让你强烈怀疑存在内存泄漏。你需要结合多个指标的趋势来判断。

vmstat 1   # 每秒刷新一次

输出示例:

procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 1  0  13312  12000  5000  60000    0    0    10    20  100  200  5  2 93  0  0

重点关注:

字段全称含义
swpdswapped memory已使用的 Swap 总量(KB)
freefree memory空闲物理内存(KB)
buffbuffer块设备缓冲区(KB)(块设备的元数据 / 裸块数据(如超级块、inode 表、磁盘块))
cachecache页面缓存(KB)(文件内容(普通文件数据页))
siswap in每秒从 Swap 读回内存的量(KB/s)
soswap out每秒写入 Swap 的量(KB/s)

🚨 模式 1:free持续下降,swpd持续上升

时间   free      swpd
T1    500000    0
T2    450000    50000
T3    380000    150000
T4    300000    280000
T5    180000    420000   ← free 快没了,swpd 猛涨

解读:物理内存被不断消耗,内核被迫把匿名页换出到 Swap。

这不是泄漏的确凿证据,但嫌疑极大——尤其是没有新进程启动的情况下。

🚨 模式 2:so(swap out)持续非零

时间   si   so
T1     0    0
T2     0    1024
T3     0    4096
T4     0    8192     ← 持续往外换出
T5     0    12288

解读:内核一直在把内存页写到 Swap,说明物理内存持续不足

如果同时 free很低且不回升,泄漏可能性很高。

🚨 模式 3:free极低但 cache不高

时间   free   cache
T1    50000  200000
T2    48000  190000
T3    46000  185000
T4    44000  182000

解读free已经很低,但 cache也不高(不像被缓存占用了),说明内存是被匿名页(进程堆/栈) 吃掉了,这是泄漏的典型特征。

✅ 正常模式(对比):高峰期后回落

时间   free      swpd     so
09:00  500000    0        0
10:00  200000    80000    2048    ← 业务高峰
11:00  350000    40000    0       ← 回落了,正常
12:00  480000    2000     0

解读:内存压力是暂时的,业务结束后释放,这不是泄漏。

实战排查流程

Step 1:用 vmstat捕获可疑趋势

# 每 5 秒采集一次,持续 1 小时
vmstat 5 > /tmp/vmstat.log

然后分析:

awk '{print NR, $4, $3}' /tmp/vmstat.log | tail -20

如果 free列持续下降、swpd持续上升 → 进入下一步。

Step 2:定位哪个进程在泄漏

bash
# 同时采集进程级数据
while true; do
    date >> /tmp/rss.log
    ps aux --sort=-rss | head -5 >> /tmp/rss.log
    sleep 10
done

看哪个进程的 RSS 只增不减。

Step 3:确认泄漏(排除正常增长)

# 看进程的 Swap 占用
cat /proc/<PID>/status | grep VmSwap

# 看进程的堆内存变化
pmap -x <PID> | tail -5

如果堆(heap)持续增长、且进程没有做合理的释放 → 确认泄漏。


vmstat的角色是:快速发现问题苗头,然后交给 ps/ pmap/ valgrind精确定位

vmstat判断内存泄漏的口诀:

free 一直跌,swpd 一直涨,so 一直出,cache 还不高 → 泄漏没跑了

如果这三条同时满足,基本可以确定有进程在泄漏内存,接下来就是用 pspmap抓凶手。

bash
# 查看进程 1234 的内存映射
pmap 1234

# 显示更详细信息(含 RSS 和脏页)
pmap -x 1234

# 显示设备映射信息
pmap -d 1234

输出示例

bash
pmap -x 1234
Address           Kbytes     RSS   Dirty Mode  Mapping
0000555555400000    4024    2012       0 r-x-- app
0000555555800000     128     108      28 r---- app
0000555555820000      48      36      24 rw--- app
00007ffff7a00000    1800     892       0 r-x-- libc.so.6
00007ffff7bc0000    2048       0       0 ----- libc.so.6
00007ffff7dc0000      16      12       8 r---- libc.so.6
00007ffff7dc4000       8       8       8 rw--- libc.so.6
00007ffffffde000     132     132      24 rw--- [stack]
00007ffffffdf000       8       8       0 r-x-- [vdso]
---------------- ------- ------- -------
total kB           48344   21292     364
含义
Address虚拟地址起始位置
Kbytes映射段大小(KB)
RSS实际驻留物理内存(KB)
Dirty脏页(被修改过的页,KB)
Mode权限(r=读 w=写 x=执行 -=无)
Mapping映射对象(可执行文件、库、堆、栈等)
  • 代码段(r-x):可执行文件
  • 数据段(rw-):全局变量
  • (heap):malloc分配的区域
  • (stack):线程栈
  • 共享库:libc、libstdc++ 等
  • 匿名映射mmap(NULL, ...)分配的内存
  • vdso/vvar:内核映射的加速区域

当然也有⽤于内存调试,内存泄漏检测以及性能分析的软件开发⼯具 valgrind 这样的⼯具来进⾏内存泄漏的检测。

37、说⼀下平衡⼆叉树、⾼度平衡⼆叉树(AVL)

⼆叉树:任何节点最多只允许有两个⼦节点,称为左⼦节点和右⼦节点,以递归的⽅式定义⼆叉树为,⼀个⼆叉树如果不为空,便是由⼀个根节点和左右两个⼦树构成,左右⼦树都可能为空。

⼆叉搜索树:⼆叉搜索树可以提供对数时间的元素插⼊和访问。节点的放置规则是:任何节点的键值⼀定⼤于其左⼦树的每⼀个节点的键值,并⼩于其右⼦树中的每⼀个节点的键值。因此⼀直向左⾛可以取得最⼩值,⼀直向右⾛可以得到最⼤值。插⼊:从根节点开始,遇键值较⼤则向左,遇键值较⼩则向右,直到尾端,即插⼊点。删除:如果删除点只有⼀个⼦节点,则直接将其⼦节点连⾄⽗节点。如果删除点有两个⼦节点,以右⼦树中的最⼩值代替要删除的位置

平衡⼆叉树:其实对于树的平衡与否没有⼀个绝对的标准,“平衡”的⼤致意思是:没有任何⼀个节点过深,不同的平衡条件会造就出不同的效率表现。以及不同的实现复杂度。有数种特殊结构例如 AVL-tree, RB-tree, AA-tree,均可以实现平衡⼆叉树。

AVL-tree :⾼度平衡的平衡⼆叉树(严格的平衡⼆叉树)AVL-tree 是要求任何节点的左右⼦树⾼度相差最多为 1 的平衡⼆叉树。当插⼊新的节点破坏平衡性的时候,从下往上找到第⼀个不平衡点,需要进⾏单旋转,或者双旋转进⾏调整。

特性AVL树红黑树
平衡标准严格平衡:任意节点的左右子树高度差 ≤ 1近似平衡:最长路径不超过最短路径的 2 倍
旋转频率插入/删除后可能多次旋转插入最多 2 次旋转,删除最多 3 次旋转
查找性能更快(树更矮)❌ 稍慢(树略高)
插入/删除性能❌ 较慢(旋转多)更快(旋转少)
适用场景查找密集(如数据库索引)插入/删除密集(如 map/set 实现)

38、说⼀下红⿊树(RB-tree)

红⿊树的定义:

性质1:每个节点要么是⿊⾊,要么是红⾊。

性质2:根节点是⿊⾊。

性质3:每个叶⼦节点(NIL)是⿊⾊。

性质4:每个红⾊结点的两个⼦结点⼀定都是⿊⾊。

性质5:任意⼀结点到每个叶⼦结点的路径都包含数量相同的⿊结点。

39、说⼀下 define、const、typedef、inline 使⽤⽅法?

1、const 与 #define 的区别

const 定义的常量是变量带类型,⽽ #define 定义的只是个常数不带类型;

define 只在预处理阶段起作⽤,简单的⽂本替换,⽽ const 在编译、链接过程中起作⽤

define 只是简单的字符串替换没有类型检查。⽽const是有数据类型的,是要进⾏判断的,可以避免⼀些低级错误;

define 预处理后,占⽤代码段空间,const 占⽤数据段空间;

const 不能重定义,⽽ define 可以通过 #undef 取消某个符号的定义,进⾏重定义;

define 独特功能,⽐如可以⽤来防⽌⽂件重复引⽤。

2、#define 和别名 typedef 的区别

执⾏时间不同,typedef 在编译阶段有效,typedef 有类型检查的功能;#define 是宏定义,发⽣在预处理阶段,不进⾏类型检查;

功能差异,typedef ⽤来定义类型的别名,定义与平台⽆关的数据类型,与 struct 的结合使⽤等。

#define 不只是可以为类型取别名,还可以定义常量、变量、编译开关等。

作⽤域不同,#define 没有作⽤域的限制,只要是之前预定义过的宏,在以后的程序中都可以使⽤。

⽽ typedef 有⾃⼰的作⽤域。

3、define 与 inline 的区别

#define是关键字,inline是函数;

宏定义在预处理阶段进⾏⽂本替换,inline 函数在编译阶段进⾏替换

inline 函数有类型检查,相⽐宏定义⽐较安全;

扩展:

40、预处理,编译,汇编,链接程序的区别

⼀段⾼级语⾔代码经过四个阶段的处理形成可执⾏的⽬标⼆进制代码。

预处理器→编译器→汇编器→链接器:最难理解的是编译与汇编的区别。

这⾥采⽤《深⼊理解计算机系统》的说法。

预处理阶段:写好的⾼级语⾔的程序⽂本⽐如 hello.c,预处理器根据 #开头的命令,修改原始的程序,如#include<stdio.h> 将把系统中的头⽂件插⼊到程序⽂本中,通常是以 .i 结尾的⽂件。

编译阶段:编译器将 hello.i ⽂件翻译成⽂本⽂件hello.s,这个是汇编语⾔程序。⾼级语⾔是源程序。所以注意概念之间的区别。汇编语⾔程序是⼲嘛的?每条语句都以标准的⽂本格式确切描述⼀条低级机器语⾔指令。不同的⾼级语⾔翻译的汇编语⾔相同。

汇编阶段:汇编器将 hello.s 翻译成机器语⾔指令。把这些指令打包成可重定位⽬标程序,即.o⽂件。hello.o是⼀个⼆进制⽂件,它的字节码是机器语⾔指令,不再是字符。前⾯两个阶段都还有字符。

链接阶段:⽐如 hello 程序调⽤ printf 程序,它是每个 C 编译器都会提供的标准库 C 的函数。这个函数存在于⼀个名叫 printf.o 的单独编译好的⽬标⽂件中,这个⽂件将以某种⽅式合并到 hello.o 中。链接器就负责这种合并。得到的是可执⾏⽬标⽂件。

41、说⼀下 fork,wait,exec 函数

⽗进程产⽣⼦进程使⽤ fork 拷⻉出来⼀个⽗进程的副本,此时只拷⻉了⽗进程的⻚表,两个进程都读同⼀块内存

当有进程写的时候使⽤写实拷⻉机制分配内存,exec 函数可以加载⼀个 elf ⽂件去替换⽗进程,从此⽗进程和⼦进程就可以运⾏不同的程序了。

fork 从⽗进程返回⼦进程的 pid,从⼦进程返回 0,调⽤了 wait 的⽗进程将会发⽣阻塞,直到有⼦进程状态改变,执⾏成功返回 0,错误返回 -1。

exec 执⾏成功则⼦进程从新的程序开始运⾏,⽆返回值,执⾏失败返回 -1。

ELF = Executable and Linkable Format(可执行与可链接格式)

它是 Linux 下可执行文件、目标文件、共享库的标准文件格式。相当于 Windows 下的 PE(Portable Executable)格式。

  1. fork()创建子进程
c
pid_t pid = fork();

if (pid == 0) {
    // 子进程:这里是父进程的完整副本
    // 有独立的地址空间(写时复制)
    printf("我是子进程\n");
    
    // 子进程可以继续运行父程序的代码
} else {
    // 父进程
    printf("我是父进程,子进程 PID = %d\n", pid);
}

fork()之后,父子进程运行相同的程序(相同的 ELF 文件)。

  1. exec()替换当前进程
c
#include <unistd.h>

// exec 家族函数
//exec[l or v][p][e]
//l = list(列表形式传参)
//v = vector(数组形式传参)
//p = PATH(从 PATH 环境变量中搜索程序)
//e = environment(自定义环境变量)
execl("/bin/ls", "ls", "-l", NULL);
execv("/bin/ls", args);
execle("/bin/ls", "ls", "-l", NULL, envp);
execve("/bin/ls", args, envp);
execlp("ls", "ls", "-l", NULL);
execvp("ls", args);

exec()做的事情:

exec() 之前:
┌─────────────────────┐
│ 当前进程            │
│ 运行的是 shell 程序 │
│ 代码段 = /bin/bash  │
│ 数据段 = bash 的变量│
│ 堆栈 = bash 的状态  │
└─────────────────────┘

调用 exec("/bin/ls") 之后:
┌─────────────────────┐
│ 同一个进程          │  ← PID 不变
│ 但现在运行 ls 程序  │
│ 代码段 = /bin/ls    │  ← 被替换
│ 数据段 = ls 的变量  │  ← 被替换
│ 堆栈 = ls 的状态    │  ← 全新
│ 除了 PID,全部重置  │
└─────────────────────┘

关键点exec()不是创建新进程,而是用新的 ELF 文件替换当前进程的内存映像

  1. 经典组合:fork()+ exec()
c
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程
        printf("子进程即将执行 ls 命令\n");
        
        // 用 ls 程序替换子进程
        execl("/bin/ls", "ls", "-l", "/home", NULL);
        
        // 如果 exec 失败才会执行到这里
        perror("exec failed");
        exit(1);
    } else {
        // 父进程等待子进程结束
        wait(NULL);
        printf("子进程执行完毕\n");
    }
    
    return 0;
}

执行流程:

bash
shell (bash)

  ├── fork()

      ├── 子进程 (bash 的副本)

      └── exec("/bin/ls")

             └── 子进程变成 ls 程序
                  运行 ls,然后退出

      └── 父进程 (bash)
            wait() 等待子进程结束

  └── 子进程结束后,bash 继续运行
  1. 完整的内存变化图
fork() 之后(写时复制):
┌──────────────┐      ┌──────────────┐
│ 父进程       │      │ 子进程       │
│ 代码: bash   │      │ 代码: bash   │ ← 共享(写时复制)
│ 数据: bash   │      │ 数据: bash   │ ← 共享(写时复制)
│ 堆: bash     │      │ 堆: bash     │ ← 共享(写时复制)
│ 栈: bash     │      │ 栈: bash     │ ← 独立
│ PID: 1000    │      │ PID: 1001    │
└──────────────┘      └──────────────┘

子进程调用 exec("/bin/ls") 之后:
┌──────────────┐      ┌──────────────┐
│ 父进程       │      │ 子进程       │
│ 代码: bash   │      │ 代码: ls     │ ← 全新加载
│ 数据: bash   │      │ 数据: ls     │ ← 全新加载
│ 堆: bash     │      │ 堆: ls       │ ← 全新分配
│ 栈: bash     │      │ 栈: ls       │ ← 全新分配
│ PID: 1000    │      │ PID: 1001    │ ← PID 不变!
└──────────────┘      └──────────────┘

42、动态编译与静态编译

静态编译,编译器在编译可执⾏⽂件时,把需要⽤到的对应动态链接库中的部分提取出来,连接到可执⾏⽂件中去,使可执⾏⽂件在运⾏时不需要依赖于动态链接库

动态编译,可执⾏⽂件需要附带⼀个动态链接库,在执⾏时,需要调⽤其对应动态链接库的命令。所以其优点⼀⽅⾯是缩⼩了执⾏⽂件本身的体积,另⼀⽅⾯是加快了编译速度,节省了系统资源。缺点是哪怕是很简单的程序,只⽤到了链接库的⼀两条命令,也需要附带⼀个相对庞⼤的链接库;⼆是如果其他计算机上没有安装对应的运⾏库,则⽤动态编译的可执⾏⽂件就不能运⾏。

43、动态链接和静态链接区别

静态连接库就是把 (lib) ⽂件中⽤到的函数代码直接链接进⽬标程序,程序运⾏的时候不再需要其它的库⽂件;动态链接就是把调⽤的函数所在⽂件模块(DLL)和调⽤函数在⽂件中的位置等信息链接进⽬标程序,程序运⾏的时候再从 DLL 中寻找相应函数代码,因此需要相应DLL ⽂件的⽀持。

静态链接库与动态链接库都是共享代码的⽅式,如果采⽤静态链接库,则⽆论你愿不愿意,lib中的指令都全部被直接包含在最终⽣成的 EXE ⽂件中了。但是若使⽤ DLL,该 DLL 不必被包含在最终 EXE ⽂件中,EXE ⽂件执⾏时可以“动态”地引⽤和卸载这个与 EXE 独⽴的 DLL ⽂件。

静态链接库和动态链接库的另外⼀个区别在于静态链接库中不能再包含其他的动态链接库或者静态库,⽽在动态链接库中还可以再包含其他的动态或静态链接库。

动态库就是在需要调⽤其中的函数时,根据函数映射表找到该函数然后调⼊堆栈执⾏。如果在当前⼯程中有多处对dll⽂件中同⼀个函数的调⽤,那么执⾏时,这个函数只会留下⼀份拷⻉。

但如果有多处对 lib ⽂件中同⼀个函数的调⽤,那么执⾏时该函数将在当前程序的执⾏空间⾥留下多份拷⻉,⽽且是⼀处调⽤就产⽣⼀份拷⻉。

44、动态联编与静态联编

在 C++ 中,联编是指⼀个计算机程序的不同部分彼此关联的过程。按照联编所进⾏的阶段不同,可以分为静态联编和动态联编;

静态联编是指联编⼯作在编译阶段完成的,这种联编过程是在程序运⾏之前完成的,⼜称为早期联编。要实现静态联编,在编译阶段就必须确定程序中的操作调⽤(如函数调⽤)与执⾏该操作代码间的关系,确定这种关系称为束定,在编译时的束定称为静态束定。静态联编对函数的选择是基于指向对象的指针或者引⽤的类型。其优点是效率⾼,但灵活性差。

动态联编是指联编在程序运⾏时动态地进⾏,根据当时的情况来确定调⽤哪个同名函数,实际上是在运⾏时虚函数的实现。这种联编⼜称为晚期联编,或动态束定。动态联编对成员函数的选择是基于对象的类型,针对不同的对象类型将做出不同的编译结果。

C++中⼀般情况下的联编是静态联编,但是当涉及到多态性和虚函数时应该使⽤动态联编。动态联编的优点是灵活性强,但效率低。动态联编规定,只能通过指向基类的指针或基类对象的引⽤来调⽤虚函数,其格式为:指向基类的指针变量名->虚函数名(实参表)基类对象的引⽤名.虚函数名(实参表)

实现动态联编三个条件:

  • 必须把动态联编的⾏为定义为类的虚函数;

  • 类之间应满⾜⼦类型关系,通常表现为⼀个类从另⼀个类公有派⽣⽽来;

  • 必须先使⽤基类指针指向⼦类型的对象,然后直接或间接使⽤基类指针调⽤虚函数;

哈喽,我是⼩贺哥,就爱分享编程知识,如果觉得⽂章对你有帮助,别忘记关注我哦!

C++ 语⾔基础篇 图 32

用心记录,持续成长